# -*- coding: utf-8 -*-
"""
doc_events.py —— WMS 与库存相关单据的文档事件处理

作用概述：
- 为 hooks.py 中的 doc_events 提供实现，在单据提交（on_submit）与取消（on_cancel）时触发。
- 根据不同单据类型（采购收货、发货、库存调整、物料移动等）生成或清理对应的仓库任务（Warehouse Task）。
- 在拣货（Pick Up）场景自动分派货位（Storage Bin），依据现有库存优先分配。

设计原则：
- 保持事件函数无副作用（除数据库写入外），不做 UI 或缓存相关操作。
- 业务逻辑清晰、注释详细，便于后续扩展与维护。
- 避免大范围查询，尽可能使用映射与轻量查询。

维护提示：
- 若引入新的单据类型或业务场景，优先在 update_warehouse_task 中补充路由逻辑。
- 自动分派货位策略可根据业务规则调整（如先进先出、批次优先、最大可用量优先等）。
"""

import frappe
from frappe.utils import flt
from pypika.terms import Tuple
from frappe.model.mapper import get_mapped_doc


def delete_draft_warehouse_task(doc, method):
    """
    取消单据时删除对应的草稿仓库任务（Warehouse Task）

    触发场景：
    - hooks.py 中为相关 DocType 配置的 on_cancel 事件

    删除条件：
    - voucher_type: 任务关联的单据类型（如 Delivery Note、Purchase Receipt 等）
    - voucher_no: 任务关联的单据编号
    - docstatus = 0：仅删除草稿状态任务，避免误删已提交任务

    参数:
        doc (Document): 触发事件的单据文档实例
        method (str): 事件方法名称（框架传入，未使用）
    """
    frappe.db.delete('Warehouse Task', {
        'voucher_type': doc.doctype,
        'voucher_no': doc.name,
        'docstatus': 0
    })


def update_warehouse_task(doc, method):
    """
    单据提交后，根据业务类型创建对应的仓库任务（Pick Up / Put Away）

    核心策略：
    - Stock Reconciliation（库存盘点）：
      - quantity_difference < 0 → 视为出库差异 → 生成 Pick Up 任务
      - quantity_difference > 0 → 视为入库差异 → 生成 Put Away 任务
    - Stock Entry（物料移动）：
      - purpose == 'Material Transfer' → 同时生成 Pick Up 与 Put Away
      - purpose == 'Material Issue' → 其他出库 → 生成 Pick Up
    - 其他单据：
      - Delivery Note（发货单）：items[0].qty > 0 → 生成 Pick Up（拣货/出库）
      - Purchase Receipt（采购收货）：items[0].qty < 0 → 生成 Pick Up（逆向场景）
      - 否则默认生成 Put Away（上架/入库）

    参数:
        doc (Document): 当前提交的单据
        method (str): 框架传入事件方法名（未使用）

    注意：
    - 本函数只处理 on_submit；取消时由 delete_draft_warehouse_task 清理草稿任务。
    """
    if doc.doctype == 'Stock Reconciliation':
        # 盘点差异为负：拣货（Pick Up）；为正：上架（Put Away）
        if any(True for row in doc.items if flt(row.quantity_difference) < 0):
            create_warehouse_task(doc, "Pick Up")
        if any(True for row in doc.items if flt(row.quantity_difference) > 0):
            create_warehouse_task(doc, "Put Away")
    elif (doc.doctype == 'Stock Entry' and doc.purpose == 'Material Transfer'):
        # 物料转移：先从源仓拣货，再在目标仓上架
        create_warehouse_task(doc, "Pick Up")
        create_warehouse_task(doc, "Put Away")
    elif doc.doctype == 'Stock Entry' and doc.purpose == 'Material Issue':
        # 其他出库：仅拣货
        create_warehouse_task(doc, "Pick Up")
    else:
        # 其他单据按首行数量方向判定任务类型
        if ((doc.doctype == "Delivery Note" and doc.items[0].qty > 0) or 
            (doc.doctype == "Purchase Receipt" and doc.items[0].qty < 0)):
            warehouse_task_type = "Pick Up"
        else:
            warehouse_task_type = "Put Away"
        create_warehouse_task(doc, warehouse_task_type)


def create_warehouse_task(doc, warehouse_task_type=None):
    """
    基于源单据生成仓库任务（Warehouse Task）及其明细（Warehouse Task Detail）

    参数:
        doc (Document): 源单据（Delivery Note / Purchase Receipt / Stock Entry / Stock Reconciliation）
        warehouse_task_type (str | None): 任务类型（"Pick Up" 或 "Put Away"），为空时按业务自动判定

    实现要点：
    - set_missing_values：设置任务头的关键字段（任务类型、关联单据类型与编号）。
    - update_item：处理明细字段映射，兼容不同源单据的字段名差异。
      - Pick Up：优先使用 s_warehouse（源仓），否则回退 warehouse
      - Put Away：优先使用 t_warehouse（目标仓），否则回退 warehouse
      - Stock Reconciliation：根据差异数量填充为绝对值；转换率置为 1
      - 通用：复制 stock_qty / stock_uom；若存在 transfer_qty 且 stock_qty 为空，则用 transfer_qty
      - 批次号、默认货位等字段需手工赋值（避免复制限制）
    - item_condition：明细过滤条件
      - 仅当仓库启用货位（enable_storage_bin）才生成任务明细
      - 盘点场景按数量差异正负决定拣货/上架
    - 获取 Item 的库存单位（盘点场景）、默认货位（Item Default）、启用货位的仓库列表
    - 使用 get_mapped_doc 将源单据映射为任务与任务明细，满足明细过滤与后处理
    - 拣货（Pick Up）时自动调用 auto_assign_storage_bin 进行货位分派
    - 若生成的任务包含明细，则插入任务（ignore_permissions = 1）

    返回:
        None（通过数据库插入产生副作用）
    """
    def set_missing_values(source, target):
        # 任务头字段设置：任务类型、关联单据类型与编号
        target.warehouse_task_type = warehouse_task_type 
        target.voucher_type = source.doctype
        target.voucher_no = source.name

    def update_item(source, target, source_parent):
        """
        明细字段映射与补齐

        说明：
        - 针对采购入库、销售出库、物料移动、库存调账等源单据，
          在映射为任务明细时字段名存在差异，需要统一转换。
        """
        # 仓库字段按任务类型选择源/目标仓
        if warehouse_task_type == "Pick Up":
            target.warehouse = source.get('s_warehouse') or source.get('warehouse')
        else:
            target.warehouse = source.get('t_warehouse') or source.get('warehouse')

        # 库存盘点的单位与数量处理
        if source_parent.doctype == "Stock Reconciliation":
            target.uom = item_uom_map.get(target.item_code)
            target.stock_uom = target.uom
            target.qty = abs(flt(source.quantity_difference))
            target.stock_qty = target.qty
            target.conversion_factor = 1
        else:
            # stock_qty 被设置为不可复制，需手工赋值
            target.stock_qty = source.get('stock_qty', 0)
            target.stock_uom = source.get('stock_uom')
            # 对于 Stock Entry，若存在 transfer_qty 且未填充 stock_qty，则使用 transfer_qty
            if hasattr(source, 'transfer_qty') and not target.stock_qty:
                target.stock_qty = source.transfer_qty

        # 批次号字段被设置为不可复制，需手工赋值
        target.batch_no = source.get('batch_no')

        # 默认货位（来自 Item Default），若无则为空
        target.storage_bin = storage_bin_map.get(target.item_code)
    
    def item_condition(child_doc):
        """
        明细过滤条件

        逻辑：
        - 仅当对应仓库启用货位（enable_storage_bin = 1）才允许生成任务明细。
        - 库存盘点场景：
          - Pick Up：quantity_difference < 0
          - Put Away：quantity_difference > 0
        - 非盘点场景：只要仓库启用货位即允许生成任务明细。
        """
        result = False
        warehouse = (child_doc.get('s_warehouse') if warehouse_task_type == "Pick Up" 
            else child_doc.get('t_warehouse')) or child_doc.get('warehouse')
        if child_doc.doctype == "Stock Reconciliation Item":
            if bool(warehouse in enable_storage_bin_warehouse):
                if warehouse_task_type == "Pick Up":
                    result = bool(flt(child_doc.quantity_difference) < 0)
                else:
                    result = bool(flt(child_doc.quantity_difference) > 0) 
        else:
            result = bool(warehouse in enable_storage_bin_warehouse)
        return result

    # 库存盘点场景：预先获取 Item 的库存单位（stock_uom）映射
    if doc.doctype == "Stock Reconciliation":
        item_uom_map = frappe._dict(frappe.get_all(
            'Item',
            filters={'name': ('in', {row.item_code for row in doc.items})},
            fields=['name', 'stock_uom'],
            as_list=1
        ))

    # 获取物料默认货位（Item Default）
    storage_bin_map = frappe._dict(frappe.get_all(
        'Item Default',
        filters={
            'parent': ('in', {row.item_code for row in doc.items}),
            'company': doc.company
        },
        fields=['parent', 'default_storage_bin'],
        as_list=1
    ))

    # 仅针对启用货位的仓库生成任务
    enable_storage_bin_warehouse = frappe.get_all(
        'Warehouse',
        filters={
            'name': ('in', {
                (row.get('s_warehouse') or row.get('warehouse')) if warehouse_task_type == "Pick Up"
                else (row.get('t_warehouse') or row.get('warehouse')) for row in doc.items
            }),
            'enable_storage_bin': 1
        },
        pluck='name'
    )

    if enable_storage_bin_warehouse:
        # 子表类型名称：Stock Entry 的子表为 Detail，其它为 Item
        child_doctype = f"{doc.doctype} {'Item' if doc.doctype != 'Stock Entry' else 'Detail'}"

        # 将源单据映射为仓库任务及明细
        warehoue_task_doc = get_mapped_doc(
            doc.doctype,
            doc.name,
            {
                doc.doctype: {
                    "doctype": "Warehouse Task",
                    "validation": {"docstatus": ["=", 1]},
                },
                child_doctype: {
                    "doctype": "Warehouse Task Detail",
                    "postprocess": update_item,
                    "condition": item_condition
                },
            },
            {},
            set_missing_values
        )

        # 拣货任务自动进行货位分派
        if warehoue_task_doc.warehouse_task_type == "Pick Up":
            auto_assign_storage_bin(warehoue_task_doc)

        # 仅当存在明细时插入任务
        if warehoue_task_doc.items:
            warehoue_task_doc.insert(ignore_permissions=1)


def auto_assign_storage_bin(doc):
    """
    自动分派货位（Storage Bin），仅在拣货（Pick Up）任务中调用

    策略说明：
    - 按库存记录（Storage Bin Qty）分配可用数量到任务明细的各项。
    - 若业务单位与库存单位不同，基于 conversion_factor 转换为业务单位。
    - 允许轻微超量（小于 1% 且差值小于 5），以减少尾差。

    参数:
        doc (Document): Warehouse Task 文档（含 items 明细）

    副作用：
    - 修改 doc.items 列表，将分派后的明细替换进任务。
    """
    # 基于库存数量，如果业务单位与库存单位不同，基于转换率转成业务单位

    idx = 1
    items = []
    storage_bin_assigned = False
    messages = []  # 可用于记录分派过程信息（当前未输出）
    bin_qty_list = []

    # 无批次的键集合（item_code, warehouse）
    key_tuple_no_batch = {(row.item_code, row.warehouse) for row in doc.items if not row.batch_no}
    if key_tuple_no_batch:
        bin_qty_list = get_bin_qty_list(key_tuple_no_batch)

    # 有批次的键集合（item_code, warehouse, batch_no）
    key_tuple_with_batch = {(row.item_code, row.warehouse, row.batch_no) for row in doc.items if row.batch_no}
    if key_tuple_with_batch:
        bin_qty_list.extend(get_bin_qty_list(key_tuple_with_batch))

    # 遍历任务明细，按库存记录逐步分派
    for item in doc.items:
        unallocated_qty = item.stock_qty
        allocated_qty, actual_qty = 0, 0

        # 过滤出与该明细匹配的库存记录（同物料、同仓库、批次匹配）
        item_bin_qty_list = [r for r in bin_qty_list if 
            r.item_code == item.item_code and r.warehouse == item.warehouse
            and (not item.batch_no or r.batch_no == item.batch_no)]

        for row in item_bin_qty_list or []:
            if row.qty <= 0:
                continue
            storage_bin_assigned = True

            # 分派数量：若库存足够，允许轻微超量；否则分派全部库存
            if row.qty >= unallocated_qty:
                if ((row.qty - unallocated_qty) / item.stock_qty * 100 < 1) and (row.qty - unallocated_qty) < 5:
                    allocated_qty = row.qty
                else:
                    allocated_qty = unallocated_qty
            else:
                allocated_qty = row.qty

            # 扣减该货位库存
            row.qty -= allocated_qty

            # 将当前明细的货位设置为分派的货位
            item.storage_bin = row.storage_bin

            # 更新未分派数量
            unallocated_qty -= allocated_qty
            actual_qty = row.qty

            # 复制并生成新的明细项，代表已分派到具体货位的数量
            new_item = frappe.copy_doc(item)
            [new_item.set(field, item.get(field)) for field in item.meta.get_fieldnames_with_value()]
            new_item.batch_no = row.batch_id
            new_item.stock_qty = allocated_qty
            # 转业务单位数量（按 conversion_factor）
            new_item.qty = item.stock_qty / (item.conversion_factor or 1)
            new_item.idx = idx
            idx += 1
            items.append(new_item)

            if unallocated_qty <= 0:
                break

        # 记录尾差信息（可按需输出），并保留未分派的原始明细
        if unallocated_qty < item.stock_qty:
            if unallocated_qty > 0:
                messages.append(f'物料 {item.item_code} 货位可分派数量还欠 {unallocated_qty}')
        if not allocated_qty:
            item.idx = idx
            idx += 1
            items.append(item)

    # 若发生了分派，则用重组后的明细替换
    if storage_bin_assigned:
        doc.items = items


def get_bin_qty_list(key_tuple):
    """
    查询 Storage Bin Qty 中指定键集合的库存记录

    参数:
        key_tuple (set[tuple]): 键集合
            - 无批次：{(item_code, warehouse)}
            - 有批次：{(item_code, warehouse, batch_no)}

    返回:
        list[dict]: 库存记录列表，包含物料、批次、仓库、货位、数量等字段

    说明：
    - 按收货日期与时间排序（gr_date, gr_time），便于实现先进先出等策略。
    - 使用 Query Builder 构造查询以保证性能与可维护性。
    """
    sbq_doctype = frappe.qb.DocType('Storage Bin Qty')
    query = frappe.qb.from_(sbq_doctype
    ).select(        
        sbq_doctype.item_code,
        sbq_doctype.batch_no,
        sbq_doctype.warehouse,
        sbq_doctype.storage_bin,                
        sbq_doctype.actual_qty.as_('qty')
    )

    # 按有批号与无批号两种情况构造 where 条件
    if len(list(key_tuple)[0]) == 2:
        query = query.where(Tuple(sbq_doctype.item_code, sbq_doctype.warehouse).isin(key_tuple))
    else:
        query = query.where(Tuple(sbq_doctype.item_code, sbq_doctype.warehouse, sbq_doctype.batch_no).isin(key_tuple))

    # 先进先出的排序依据
    query.orderby(sbq_doctype.gr_date, sbq_doctype.gr_time)

    qty_list = query.run(as_dict=1)
    return qty_list